iT邦幫忙

2025 iThome 鐵人賽

DAY 2
0

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師:所有權 (Ownership):變數的「單身證明」

顛覆過去對 「=」的理解

在寫過的所有程式語言中,等號 = 可能是最不起眼、最基礎的符號。

我們每天用它幾百次,給變數賦值/指派,傳遞參數,初始化物件。

它如此理所當然,以至於很少停下來思考:當我們寫下 b = a 的時候,到底發生了什麼?

作為一個長年在動態與 GC 語言中穿梭的開發者,我曾經以為我對「 =」瞭若指掌。

但 Rust 徹底顛覆了過去所理解的「 =」,而且充滿了模糊地帶的便利性妥協。

變數的「單身證明」

「單身證明」是一份法律文件,證明一個人在某個時間點上,處於「無配偶」的狀態。

這份證明的重要特質有兩個:

  1. 排他性:你不可能同時擁有一份有效的「單身證明」和一份有效的「結婚證書」。這兩種狀態是互斥的。

  2. 失效性:當你結婚的那一刻,你之前所有的「單身證明」無論是否在你手上,都立即作廢。你的法定狀態已經改變。

現在對應到 Rust 的所有權:

一個變數,就是一份「單身證明」,證明它獨佔地擁有某個值。

  • 排他性:一個值,在任何時間點,只能有一個所有者。s1 擁有 String,就沒有 s2 的份。這就是排他性。

  • 失效性:這是最關鍵的對應點。當你執行 let s2 = s1;,這不是賦值/不是指派,這是狀態轉移s1 把它擁有的 String 的所有權「過戶」給了 s2。這個行為,等同於 s1 去辦了「結婚證書」,它的「單身證明」立刻作廢

https://ithelp.ithome.com.tw/upload/images/20250916/20124462fAcB1GNkyN.png

這就是為什麼在那之後,你再也不能使用 s1

編譯器阻止你,不是因為什麼複雜的規則,而是因為 s1 的身份已經失效,它不再是那個值的合法所有者。

那些舊的、模糊的類比在這裡完全不適用。

用「指標」、「引用」這些詞彙來解釋所有權,只會造成混淆,他和過去的經驗完全沒有關聯。是一個全新的概念。

我們熟悉的 =:從「貼標籤」到「影印」

先回到熟悉的世界,看看 = 扮演的兩種主要角色。

1. Python/JavaScript 的世界:「貼標籤」的藝術

在 Python 或 JavaScript 中,在處理物件(幾乎所有非原始型別的東西都是物件)時, =更像是「貼標籤」。

# Python
a = [1, 2, 3]

這行程式碼的實際行為是:在記憶體中創建一個列表 [1, 2, 3],然後拿一張叫做 a 的便利貼,貼在這個物件上。
``

# 執行指派操作
b = a

Python 並沒有去複製一份新的 [1, 2, 3] 列表。
它只是拿了另一張叫做 b 的便利貼,這兩個標籤(ab),貼在同一個物件上(指向同一個記憶體中的資料)。

這帶來非常直接的後果,也是無數新手(甚至老手)踩過的坑:

b.append(4)
print(a)  # 輸出結果是 [1, 2, 3, 4],而不是 [1, 2, 3]

這種行為很彈性很高效(畢竟只是複製一個指標),但也對工程師的記憶增加負擔。
你必須在腦中時刻追蹤,到底有多少個「標籤」貼在了你的資料上,以及誰可能會在你不注意的時候修改它。

  • 共享同一塊記憶體

2. Golang 的世界:「影印」或「給地址」的抉擇

Golang 處理這個問題的方式則更為明確,但也更分裂。
它嚴格區分了「實值型別」和「引用型別」。

指派一個 struct 時,Go 預設會進行一次完整的「影印」:

type MyData struct {
    Value int
}

a := MyData{Value: 10}
b := a // 這裡 a 的內容被完整地複製了一份給 b

b.Value = 20
fmt.Println(a.Value) // 輸出 10,a 沒有被改變

這種方式避免了副作用,ab 是兩份完全獨立的資料。
但如果 MyData 是一個非常龐大的結構,這種預設的複製行為可能會帶來不必要的性能開銷。

於是,Go 提供了另一種選擇:指標。
你可以明確地傳遞記憶體地址,來達到類似 Python「貼標籤」的效果:

a := &MyData{Value: 10} // a 現在是一個指向 MyData 的指標
b := a                  // b 也複製了這個指標,指向同一個 MyData

b.Value = 20
fmt.Println(a.Value) // 輸出 20,a 指向的內容被改變了

Go 的設計哲學是「明確」。它強迫你在「複製一份」還是「共享一個」之間做出選擇。
這比 Python 的隱式共享要清晰,但責任也完全落在了開發者身上。
你必須「自己管理」何時該用實值以保證數據隔離,何時該用指標以提高效率。

Rust 的革命: 「=」即「所有權過戶」

那 Rust 是如何釜底抽薪解決這個問題的?

Rust 引入了一個簡單且不容妥協的規則:

一個值,在任何時候,都只能有一個「所有者」。

這個「所有者」就是持有這個值的變數。

我們把這個規則稱為所有權(Ownership)

讓我們用一個在其他語言中極其常見的 String 型別來看看這條規則的威力。

let s1 = String::from("hello");

這行程式碼和我們預想的差不多:在記憶體(堆)中分配了一塊空間來儲存 "hello",然後變數 s1 成為這塊記憶體的所有者。
s1 不只是指向資料,它還擁有一份「所有權狀」,這份權狀證明了它對這塊記憶體負有全責。

let s2 = s1;

如果這是 Python 或 Go(使用指標),s2 會成為指向同一塊記憶體的第二個引用。
如果這是 Go(使用實值),"hello" 會被複製一份。

但在 Rust 中,這兩者都不是。
這行程式碼的語義是:s1 將它擁有的 String 的「所有權」,完全轉讓給了 s2

這不是「貼標籤」,也不是「影印」,這是「資產過戶」。

s1 在完成轉讓後,就徹底失去了對這份資料的所有權。它變成了一個無效的變數,就像一張被註銷的舊房契。
這個設計最令人震撼的後果,由 Rust 強大的編譯器來執行:

println!("s1 is: {}", s1); // <-- 編譯器會在這裡劃上紅線,拒絕編譯!

編譯器會給出一個明確的錯誤訊息:borrow of moved value: s1

也就是告訴你:「你正試圖使用一個已經把它所有權『移走』(move)的變數 s1。這是非法的。」

這就是 Rust 所有權模型的核心。
它在編譯時期就徹底杜絕了資料同時被多個變數「擁有」的可能性。

https://ithelp.ithome.com.tw/upload/images/20250916/2012446254NtLTIz4P.png

這份「不便」為何是安全的基礎?

第一次遇到這特性時,我的第一想法是:「太不方便了!為什麼不能像以前一樣隨意使用變數?」之後才理解了這份「不便」背後,蘊含著何等深刻的安全考量。

1. 徹底杜絕「二次釋放」

在第一章我們提到,GC 解決了「何時釋放記憶體」的問題。
Rust 也有自動的記憶體釋放:當一個變數離開它的作用域時,它所擁有的資源會被自動清理。這個機制稱為 Drop

現在結合所有權規則思考一下:因為一個值永遠只有一個所有者,所以也就永遠只有一個變數負責在離開作用域時清理這份資源。

這就從根本上消滅了「二次釋放」這類在手動管理記憶體時極其危險的 bug。系統絕無可能對同一塊記憶體清理兩次。

2. 強迫你理清資料的生命週期

所有權模型強迫你在寫程式碼的每一刻,都清楚地知道:「這份資料現在歸誰管?它的生命週期由誰負責?」

在 Python 或 Go 中,一個複雜的物件可能被傳遞給很多個函數,它的引用散落在系統的各個角落。
我們只能「信賴」GC 能在所有引用都消失後正確地回收它。

但在 Rust 中,所有權的流動是線性的、明確的。
你要嘛把所有權轉移給下一個函數,要嘛在用完之後,所有權會隨著你的函數結束而銷毀,絕無歧義。

這種清晰的責任鏈,對於構建大型、可維護的系統至關重要。

3. 為「無畏併發」打下地基

無畏併發(Fearless Concurrency),在任何時候,一份資料都只有一個所有者 (單一所有權規則)。

在預設情況下,不可能有兩個執行緒同時去修改同一份資料,因為要修改資料,你必須先「擁有」它。而所有權是獨佔的。
在編譯期,徹底消滅了「資料競爭」(Data Race)這一併發程式設計中最常見噩夢。(我們在後續文章會深入探討這一點)

例外情況:Copy Trait

當然,你可能會問:那像整數這樣的簡單型別呢?

let x = 5;
let y = x;
println!("x is: {}", x); // 這段程式碼可以正常編譯和運行!

為什麼 x=y 之後沒有失效?
因為像 i32 這樣的基礎型別,實現了一個叫做 Copy 的特殊 trait。

對於實現了 Copy 的型別, =操作的行為是「複製」而不是「移動」。

這背後的邏輯非常務實:對於那些儲存在棧上、複製成本極低的資料(如整數、浮點數、布林值),每次都轉移所有權反而會讓程式碼變得繁瑣。因此,Rust 允許這些型別「豁免」移動語義。

但關鍵在於,「移動」是預設行為,「複製」是需要型別顯式選擇加入的特性
Rust 的設計永遠將安全和明確性放在第一位。

=」語義三分天下:貼標籤、影印、過戶

當我們在不同語言寫下 b = a,其實是在做三種完全不同的事。

  • 貼標籤(Reference/Alias):Python/JavaScript 物件賦值/指派,多個名稱指向同一份資料,副作用難以追蹤。
  • 影印(Copy by value):Golang 實值型別預設複製,安全但可能昂貴,需要開發者自行折衷。
  • 過戶(Move/Ownership transfer):Rust 預設移動,單一所有權,編譯期保證生命週期與釋放時機。

https://ithelp.ithome.com.tw/upload/images/20250916/20124462LzjTOncuJz.png

下一步:當不想過戶,只想「借閱」

下一次會把焦點放到借用(Borrowing)

  • 多個讀(不可變借用 &T唯一寫(可變借用 &mut T 的天條如何在編譯期落地?
  • 如何在不破壞所有權的前提下,實現 API 的高效共享?
  • 哪些情境需要 Rc/Arc(共享所有權)而不是借用?

相關連結與參考資源

Rust 基礎(官方)


上一篇
(Day1) Rust 記憶體管理的十字路口:告別「信賴」GC 的時代
下一篇
(Day3) Rust 借用 (Borrowing):有契約的共享
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言